Skip to content

Comments

feat(jmap): add caldav/jmap — JMAP calendar and task client#625

Open
SashankBhamidi wants to merge 35 commits intomasterfrom
feature/jmap
Open

feat(jmap): add caldav/jmap — JMAP calendar and task client#625
SashankBhamidi wants to merge 35 commits intomasterfrom
feature/jmap

Conversation

@SashankBhamidi
Copy link
Collaborator

@SashankBhamidi SashankBhamidi commented Feb 19, 2026

Adds caldav/jmap/, a new module providing JMAP calendar and task support alongside the existing CalDAV client. Zero modifications to any existing file.

The module follows the same layered sans-I/O design as the CalDAV side: pure method builders/parsers in methods/, dataclasses in objects/, bidirectional iCalendar ↔ JSCalendar conversion in convert/, HTTP + session logic in client.py and async_client.py.

Usage documentation in docs/source/jmap.rst covers auth, event CRUD, search, incremental sync, tasks, async API, and error handling.

Session bootstrap (session.py): GET /.well-known/jmap, resolve relative apiUrl via urljoin (Cyrus returns a relative path), select account via primaryAccounts[CALENDAR_CAPABILITY] with a fallback scan of all accounts. Raises JMAPCapabilityError if no calendar-capable account is found.

Auth (client.py): Basic when username is supplied, Bearer when only password is given, or a pre-built auth object via the auth kwarg. No 401-challenge-retry — a 401/403 from session or API endpoint raises JMAPAuthError immediately. JMAPError extends DAVError so existing CalDAV exception handlers catch JMAP errors too.

Calendar operations on both JMAPClient and AsyncJMAPClient: get_calendars, create_event, get_event, update_event, delete_event, search_events, get_sync_token, get_objects_by_sync_token. search_events uses a single batched request — CalendarEvent/query + a result reference into CalendarEvent/get — one HTTP round-trip regardless of result size. get_objects_by_sync_token raises JMAPMethodError(error_type="serverPartialFail") when the server truncates the change list (hasMoreChanges: true).

Task operations (RFC 9553): get_task_lists, create_task, get_task, update_task, delete_task. Task methods send _TASK_USING = [CORE_CAPABILITY, TASK_CAPABILITY]; servers without urn:ietf:params:jmap:tasks return an error methodResponse which _request converts to JMAPMethodError. Cyrus does not implement RFC 9553, so task integration tests are deferred.

AsyncJMAPClient (async_client.py) mirrors every method as a coroutine. Each request opens its own niquests.AsyncSession — no long-lived connection is held.

iCalendar ↔ JSCalendar conversion (convert/): full bidirectional mapping covering DTSTART (all-day, floating, UTC, IANA-tz, non-IANA TZID passthrough), DTEND/DURATION, RRULE, EXRULE, EXDATE, RECURRENCE-ID overrides, ORGANIZER/ATTENDEE with roles and participation status, VALARM (relative and absolute triggers), CATEGORIES, LOCATION, CLASS, TRANSP, SEQUENCE, PRIORITY, COLOR. Shared duration/datetime primitives (_timedelta_to_duration, _duration_to_timedelta, _format_local_dt) live only in convert/_utils.py.

Entry points (__init__.py): get_jmap_client(**kwargs) and get_async_jmap_client(**kwargs) read from the same sources as get_davclient — explicit kwargs, env vars, config file — and return None when no configuration is found.

254 unit tests (zero network, all mocked). 17 integration tests against live Cyrus Docker (6 sync + 6 async event CRUD/search/sync, 5 session/calendar checks); auto-skipped if server unreachable.

Introduces caldav/jmap/ as a purely additive package providing JMAP
calendar support alongside the existing CalDAV client. No existing
files are modified.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

@SashankBhamidi
Copy link
Collaborator Author

SashankBhamidi commented Feb 20, 2026

@tobixen Is this better now? I’ll pull it tomorrow and work on CI errors. I committed the changes as suggestions for now.

This comment was marked as resolved.

docs(jmap): JMAP usage documentation and autodoc stubs

This comment was marked as spam.

@SashankBhamidi SashankBhamidi marked this pull request as ready for review February 21, 2026 08:59
@SashankBhamidi SashankBhamidi changed the title feat(jmap): JMAP calendar support feat(jmap): add caldav/jmap — JMAP calendar and task client Feb 21, 2026
@tobixen
Copy link
Member

tobixen commented Feb 22, 2026

🤖 This comment is claude-generated.

Code Review: feature/jmap

Summary

Well-structured implementation. Clean layered design, passes ruff lint/format, 254 unit tests all green. A few things worth addressing before merging.


Architecture

caldav/jmap/
├── __init__.py          get_jmap_client / get_async_jmap_client factory
├── client.py            JMAPClient (sync)
├── async_client.py      AsyncJMAPClient (mirrors all public methods)
├── session.py           Session establishment + account discovery
├── error.py             Exception hierarchy
├── constants.py         Capability URNs
├── objects/             Dataclasses: JMAPCalendar, JMAPEvent, JMAPTask, JMAPTaskList
├── methods/             Pure functions: request builders + response parsers
└── convert/             iCalendar ↔ JSCalendar bidirectional conversion

Public API matches the CalDAV client pattern: get_calendars, create_event, get_event, update_event, delete_event, search_events, get_sync_token, get_objects_by_sync_token, task CRUD. Both sync and async variants.


Issues

Medium — potential crash in create_event (client.py:257):

if "new-0" in not_created:
    self._raise_set_error(session, not_created["new-0"])
return created["new-0"]["id"]   # KeyError if server omits it from both dicts

If the server returns a malformed response where "new-0" is absent from both created and not_created, you get an uncaught KeyError. Low probability but worth hardening — guard with if "new-0" not in created: or wrap in try/except KeyError.


Medium — non-deterministic participant UUIDs (ical_to_jscal.py):

Every conversion generates fresh uuid.uuid4() for participant IDs. If you fetch a JMAP event, convert to iCal, edit, and push back, participant IDs change on every call. Use uuid.uuid5(uuid.NAMESPACE_URL, email) to make them deterministic and stable across round-trips.


Medium — no SSL verification option (client.py):

DAVClient has ssl_verify_cert=False for testing against self-signed certs (Zimbra, local servers). JMAPClient has no equivalent — all requests use default requests.post() settings. Given the project's test infrastructure this will be needed.


Low — get_objects_by_sync_token makes two HTTP round-trips (client.py:410–436):

Issues CalendarEvent/changes then a separate CalendarEvent/get. search_events correctly batches query+get with a result reference in one HTTP call. The same pattern could apply here when hasMoreChanges is false.


Low — RELATED=END alarm trigger not handled (jscal_to_ical.py):

VALARM triggers with TRIGGER;RELATED=END:-PT15M (relative to event end) are silently treated as start-relative. The RELATED parameter is not checked.


Low — incomplete generic type hints on objects:

Dict fields in objects/event.py, objects/task.py etc. are typed as bare dict rather than dict[str, bool] or dict[str, dict]. Not a runtime bug, but reduces IDE support.


Low — async client has zero integration test coverage:

test_jmap_integration.py only exercises JMAPClient. AsyncJMAPClient mirrors all methods but has never been run against a real server. A smoke test (get_calendars) would at least catch obvious wiring issues.


Documentation

docs/source/jmap.rst (377 lines) is comprehensive — covers quick start, auth modes, CRUD, search, sync tokens, task API, and async usage. Good.

CHANGELOG entry has been added in the branch.

@SashankBhamidi
Copy link
Collaborator Author

Thanks for the review! Responses inline:

potential crash in create_event (client.py:257) — KeyError if server omits "new-0" from both dicts

Valid, will fix.

non-deterministic participant UUIDs — use uuid.uuid5 for stable IDs across round-trips

False positive. The UUID is a local ephemeral key within a single CalendarEvent/set call, the server assigns its own stable participant IDs. Stability across round-trips is a server responsibility in JMAP.

no SSL verification option

Noted, out of scope for this PR.

get_objects_by_sync_token makes two HTTP round-trips

Intentional. CalendarEvent/changes returns separate created and updated lists, and we need to preserve that distinction in the return value. A result reference would merge them, losing the added/modified split.

RELATED=END alarm trigger not handled

Valid, will fix.

bare dict type hints

Noted, low priority.

async client has zero integration test coverage

TestAsyncJMAPEventIntegration in test_jmap_integration.py covers create, get, update, delete, search, sync token, and incremental sync against live Cyrus, 6 tests total.

@tobixen
Copy link
Member

tobixen commented Feb 22, 2026

Sorry for not getting back with human-generated comments yet. My 15yo son was dragging me for a monster skiing trip yesterday, we managed to get home only after midnight, quite exhausted. Will have a calmer shorter skiing trip with my daughter today before getting back to this.

tobixen and others added 2 commits February 22, 2026 11:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants